Skip to content

REQ-70: mirror the verified file gate into emitted C, and route fail/vfs - #73

Merged
ib823 merged 2 commits into
mainfrom
claude/continue-solution-4gn31y
Aug 20, 2026
Merged

REQ-70: mirror the verified file gate into emitted C, and route fail/vfs#73
ib823 merged 2 commits into
mainfrom
claude/continue-solution-4gn31y

Conversation

@ib823

@ib823 ib823 commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Summary

The file family was held back in #72 because routing it would have let a compiled binary bypass a Coq-modeled access-control check that riinac run enforces — 12 gate call sites in the interpreter, 0 gating constructs in the C helpers. The gate is now mirrored, so the family routes.

A compiled binary now refuses a non-owner write exactly as the interpreter does, with the file left unchanged.

Design — recorded because the obvious approach is impossible

Emitted C cannot call into Rust. The pipeline is cc -o out one.c with nothing linked, so a single shared implementation callable from both backends would mean shipping per-target Rust staticlibs (native / wasm32 / android-arm64 / ios-arm64) and rebuilding the compile pipeline. I recommended that ABI earlier; it was not buildable, and this corrects it.

Instead the Coq model is the single source of truthdomains/VerifiedFileSystem.v (Inode / Ownership / Permission / is_owner / get_permission) — with riina-os/src/vfs.rs and the emitted C as two implementations of it, held together by a differential. The same shape as the masa civil calendar (#70) and the GF128/AES Coq⇄Rust equivalences.

The C mirrors: the inode table, first-touch ownership at mode 0644, owner ▷ group ▷ other resolution, can_read/can_write as is_root || the applicable bit, and gate_delete clearing the mapping so a re-creator owns the file. Denial exits non-zero with the interpreter's wording and cannot fall through to the filesystem.

Matched op-for-op, including the negative space. fail_ada and fail_senarai are ungated in the interpreter — an existence check is not an access — so they are ungated in C. Gating them would have been a divergence in the opposite direction: denying what the interpreter allows.

Deliberately not the host OS's permission bits. The gate is RIINA's model and must deny where the model denies even when the host would allow (the process typically owns these files). Letting the kernel decide would silently make the check a no-op.

Scope

Routed: the 8 fail_* builtins plus vfs_mula / vfs_jadi_pengguna, which are what make the gate meaningful. vfs_tulis / vfs_baca / vfs_padam stay interpreter-only — they operate on the in-memory VirtualFs with quota accounting that has no C implementation, and stubbing them would claim an enforcement this backend cannot make.

The #72 limitation is now closed

file_gate_parity.rs's active SECURITY REGRESSION arm was previously unexecutable — no vfs_* C helpers existed to build the bypass with — and I said so in that PR rather than implying it was verified. It is now negative-controlled:

deleting a single riina_gate call from the emitted fail_tulis makes the compiled binary perform the write (exit 0), and the test fails with its intended message.

Testing

  • cargo test --all (03_PROTO): 3330/0 (+7); (05_TOOLING): 323/0
  • cargo clippy --all-targets -- -D warnings clean on both workspaces
  • audit-docs.sh 0 discrepancies
  • New tests — file_differential.rs, 7 cases

The differential cases separate the resolution arms rather than demonstrating the happy path — a C gate that collapsed owner/group/other by always consulting perm_owner would pass a naive same-uid test and fail these:

case arm exercised
owner round trip perm_owner
non-owner read allowed perm_other.read at 0644
non-owner write denied perm_other.write false
non-owner append denied separate fail_tambah call site
size + lines for owner fail_panjang / fail_baca_baris read gates
delete clears ownership gate_delete drops the mapping; a different uid re-creates and owns
existence check ungated the negative space above

Also updates pkg_build's interpreter-only boundary example from fail_baca (now compiles) to vfs_baca — a deliberate choice, since the VirtualFs quota has no C implementation and so it should stay interpreter-only for as long as that holds. That test's own comment asks for exactly this update.

docs/api/STDLIB.md regenerated from the compiler: fail_* and the two setters native-only, the VirtualFs trio still interp-only.

Type

  • Bug fix
  • New feature
  • Documentation
  • Example .rii file
  • Formal proof
  • Refactor

Checklist

  • No external dependencies added (std-only, Law 8)
  • No new unsafe
  • No Admitted in Coq proofs (no proof changes; the model was already there)
  • Examples use Bahasa Melayu keywords (no example changes)

Related: keselamatan inspected, and it is not a second file family

Recorded in the REQ-70 row. Its taint/sink discipline is enforced at compile time by the type system — verified by running it, not inferred:

$ riinac check   # raw input into a sink
error: Type mismatch: expected Sanitized(String, SqlParam), found String

That fires before a backend is chosen, so compiling cannot bypass it. Only 3 of 42 carry a runtime security property — the fail_*_selamat trio, which call the same gate and are therefore covered by this PR. emit.rs has zero C helpers for any keselamatan builtin, so there is no pre-written ungated code to route by accident. Routing the remaining 39 is ordinary work.

One incidental finding: csrf_jana derives tokens from SystemTime nanos through DefaultHasher — predictable, not a CSPRNG. Honestly disclaimed in the source, but the caveat does not reach STDLIB.md, where a caller sees only Fn((), Teks, Rawak). Same REQ-47 boundary-disclosure pattern; cheap to fix with a generated caveat.


Generated by Claude Code

claude added 2 commits August 18, 2026 21:51
…te fail/vfs

REQ-70. The file family was held back because routing it would have let a
compiled binary bypass a Coq-modeled access-control check that riinac run
enforces (12 gate call sites in the interpreter, 0 gating constructs in the C
helpers). The gate is now mirrored, so the family routes.

DESIGN, recorded because the obvious approach is impossible. Emitted C cannot
call into Rust: the pipeline is `cc -o out one.c` with nothing linked, so a
single shared implementation callable from both backends would mean shipping
per-target Rust staticlibs (native/wasm32/android/ios) and rebuilding the
compile pipeline. Instead the COQ MODEL is the single source of truth —
domains/VerifiedFileSystem.v (Inode/Ownership/Permission/is_owner/
get_permission) — with riina-os/src/vfs.rs and the emitted C as two
implementations of it, held together by a differential. Same shape as the masa
civil calendar and the GF128/AES Coq-Rust equivalences.

The C mirrors: the inode table, first-touch ownership at mode 0644 (owner rw,
group/other r), owner > group > other resolution, can_read/can_write as
is_root OR the applicable bit, and gate_delete clearing the mapping so a
re-creator owns the file. Denial exits non-zero with the interpreter's wording
and cannot fall through to the filesystem.

Matched op-for-op, including the negative space: fail_ada and fail_senarai are
UNGATED in the interpreter (an existence check is not an access), so they are
ungated in C too. Gating them would have been a divergence in the opposite
direction — denying what the interpreter allows.

Deliberately NOT the host OS's own permission bits: the gate is RIINA's model
and must deny where the model denies even when the host would allow (the
process typically owns these files), so letting the kernel decide would
silently make the check a no-op.

Routed: the 8 fail_* builtins plus vfs_mula/vfs_jadi_pengguna, which are what
make the gate meaningful. vfs_tulis/vfs_baca/vfs_padam stay interpreter-only —
they operate on the in-memory VirtualFs with quota accounting that has no C
implementation, and stubbing them would claim an enforcement this backend
cannot make.

THE #72 LIMITATION IS NOW CLOSED. file_gate_parity.rs's active SECURITY
REGRESSION arm was previously unexecutable — no vfs C helpers existed to build
the bypass with — and is now negative-controlled: deleting a single riina_gate
call from the emitted fail_tulis makes the compiled binary perform the write
(exit 0) and the test fails with its intended message.

Adds file_differential.rs (7 cases), chosen to separate the resolution arms
rather than to demonstrate the happy path: owner write; non-owner read ALLOWED
at 0644; non-owner write and append DENIED; delete clears ownership so a
different uid may re-create and own; ungated existence check. A C gate that
collapsed owner/group/other by always consulting perm_owner would pass a naive
same-uid test and fail these.

Also updates pkg_build's interpreter-only boundary example from fail_baca (now
compiles) to vfs_baca — a deliberate choice rather than the next arbitrary
one, since the VirtualFs quota has no C implementation and so it should stay
interpreter-only for as long as that holds. The test's own comment asks for
exactly this update.

Verified: 03_PROTO 3330/0 (+7), 05_TOOLING 323/0, clippy clean on both,
audit-docs.sh 0 discrepancies. STDLIB.md regenerated from the compiler:
fail_* and the two setters native-only, the VirtualFs trio still interp-only.
Regenerated by the pre-push riinac verify --full at 512c6ba. Rust tests
3323 -> 3330: the +7 are file_differential.rs, which pins the mirrored C gate
against the interpreter's across the owner/group/other resolution arms. Coq
recompiled clean at 331 .vo, 0 admits, 0 axioms.
@ib823
ib823 marked this pull request as ready for review August 20, 2026 00:16
@ib823
ib823 merged commit 84acc3a into main Aug 20, 2026
11 checks passed
ib823 pushed a commit that referenced this pull request Aug 20, 2026
Metrics unchanged in substance (3330 tests, 12678 Qed, 0 admits, 0 axioms);
timestamp, commit stamp and the rebuilt WASM asset only. Published count is
correct — the fail-closed guard added after the #69 chain held for the third
consecutive chain.

dim1_dim9 promotion_ready reads true for the container-local reason recorded
at the #68/#69 chains (pinned jars on disk here). Top-line verdict unchanged:
overall_foundation FAIL.
ib823 pushed a commit that referenced this pull request Aug 20, 2026
… it exposed

REQ-70. Routes the remaining 17 security builtins, taking the family from 24/42
to 41/42. Getting there required fixing two divergences in families the plan had
already marked closed -- both invisible to the differentials those families
shipped with, and both prerequisites rather than side quests.

THE DEFERRAL WAS WRONG, AND CHECKING IT IS WHAT UNBLOCKED THIS. The previous
increment routed only the single-argument subset, reasoning that eleven members
take a pair and split_pair hands back a Value::BuiltinPartial for a non-pair
argument, which the C backend has no equivalent of. That reasoning does not
hold: every one of those signatures is typed Ty::Prod(..) -> _ in
riina-typechecker, so the curried form f(a, b) is REJECTED AT TYPE-CHECK,
identically under `riinac run` and `riinac build`, and only f((a, b)) ever
reaches a runtime. The interpreter's partial arm is unreachable from well-typed
source, so C needing no partial-application machinery costs nothing. A test now
pins that, because it is the assumption the eleven C implementations rest on.

PREREQUISITE 1 -- THE C JSON PARSER COULD NOT FAIL. riina_json_parse_value had
no error path at all: unknown input fell through to strtoll and became a value.
Five classes, each SILENT --

  input     riinac run                       compiled binary
  "xyz"     unexpected char 'x'              0
  ""        unexpected end of input          0
  "12abc"   unexpected trailing content      12
  "nul"     expected 'null'                  ()
  "[1,2"    expected ',' or ']' in array     [1,2]

A compiled program parsing attacker-controlled JSON saw a FABRICATED value where
the interpreter refuses. json_differential missed it because all nine of its
cases fed WELL-FORMED input, so a parser that cannot fail was never asked to.
And json_parse_safe/nyahsiri_selamat could not be routed at all: their whole
contract is "malformed input yields Unit", and "malformed" had no meaning on the
C side.

The parser now mirrors builtins/json.rs production-for-production, including the
parts that are not obviously deliberate: whitespace is UNICODE (str::trim), not
the ASCII four; a lone surrogate in a \uXXXX escape decodes to nothing
(char::from_u32 is None and the interpreter pushes no character); and a number
is read as u64 first, then as f64 with Rust's SATURATING as-u64 cast -- so "-5"
is 0, where strtoll plus a C cast gave 18446744073709551611. Failure is recorded
rather than raised in place, so json_urai can report it and json_parse_safe can
swallow it.

PREREQUISITE 2 -- COMPOSITE VALUES RENDERED AS THE LITERAL TEXT <value>.
riina_format handled the scalar tags and defaulted every composite one -- PAIR,
LIST, MAP and both SUM arms -- to "<value>". cetakln and ke_teks both go through
it, so a compiled program printing a list showed <value> where riinac run shows
[1, 2, 3]. That also made sahkan_panjang unroutable in practice: it returns an
Option, so its answer was unobservable in compiled code -- lowering it would
have been the REQ-79 "compiles but you cannot see the result" trap. ke_teks
additionally carried its own COPY of the scalar arms, which is how the two came
to disagree; it now delegates, so there is one switch rather than two.

The interpreter has two rendering modes that spell values differently:
builtins::format_value prints a string bare and a bool as betul/salah, while
Value's Display -- which format_value falls through to, and which is the ONLY
path a sum takes -- quotes the string and prints Rust's English true/false. So
the same bool is betul in a list and true in a sum. That is an inconsistency in
the reference, not a design, but the reference is what a compiled program must
match; both modes are mirrored rather than tidied, and pinned so that changing
it is a language decision and not codegen drift.

ROUTED: seven pair-taking modelled sinks (dom_set_html/attr, email_send/
set_header, http_post/put/delete), validate_length, the three CSRF predicates,
the safe-file trio, and the three safe parsers.

The safe-file trio share the VERIFIED GATE with fail_* -- the interpreter calls
the same fail::gate_read/gate_write/gate_delete -- so the emitted C reaches the
same riina_gate. A "safe" file op that skipped the access check would be the
REQ-72 bypass wearing the word `selamat`. They were routable only because the
gate landed in #73.

NOT ROUTED: csrf_generate/csrf_jana, the single remaining member. Its result is
not a function of its input (a token seeded from the clock and a process-local
counter), so the backends cannot be held to agreement by a differential, only to
a shape. Mirroring it would mean transcribing Rust's DefaultHasher into C to
reproduce a generator its own doc comment already marks as "a *reference* token,
not a certified CSPRNG" -- spreading that to a second implementation makes the
eventual fix twice the work. Nothing is cut off: csrf_validate takes plain Teks,
so a compiled program can carry tokens minted elsewhere, unlike the sanitizers
which would have been unreachable without baca_baris.

RECORDED, NOT FIXED -- a stdlib defect the differential surfaced.
sanitasi_json is the only producer of Disanitasi<Teks, JsonValidation> and so
the only way to reach json_urai_selamat, but it is a string-EMBEDDING escaper:
it turns {"a":1} into {\"a\":1}. Every JSON object therefore arrives malformed
and parses to Unit, because object keys are quoted; only quote-free documents
survive. Both backends agree on this, so it is a type-signature defect in the
security stdlib -- the gate on a safe PARSER should be a validation, not an
escape -- and not a divergence. Pinned in both directions so a fix has to update
the test deliberately.

Tests: keselamatan_differential 14 -> 24, json_differential 9 -> 13,
collection_differential 7 -> 9. The keselamatan additions cover the empty
allowed-origin arms (a C author reaching for strncmp alone would accept every
origin), character-vs-byte length counting, gate parity for the safe-file trio
including a denied non-owner write, and the safe parsers on each malformed
class.

Verified: 03_PROTO 3360/0 (+30), 05_TOOLING 323/0, clippy clean on both,
audit-docs.sh 0 discrepancies. STDLIB.md regenerated from the compiler.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants